Skip to content

feat(amicode): approval state machine + ledger transport (warrant surfaces, part 1) - #75

Closed
aarontrowbridge wants to merge 19 commits into
fix/amicode-receipt-currencyfrom
feat/amicode-approval-surfaces
Closed

feat(amicode): approval state machine + ledger transport (warrant surfaces, part 1)#75
aarontrowbridge wants to merge 19 commits into
fix/amicode-receipt-currencyfrom
feat/amicode-approval-surfaces

Conversation

@aarontrowbridge

Copy link
Copy Markdown
Member

Two of the three modules behind the approval card. Stacked on #74 — review that first; this diff shows only the delta once it merges.

Nothing imports these yet, so this is inert. It is landed early because the shape is what wants review, not the wiring.

Design: spec-20260727-164748-amicode-deliberation-layer-capability-warrants §9.5.

approval.ts — the state machine

(request, warrants, now, hasBridge)unavailable | pending | granted | expired. Mirrors the existing ask.ts / ask-bridge.ts / ask-card.tsx split.

Two properties inherited from the ask card deliberately:

  • The read-only interlock. No transport → unavailable, never actionable. ask-bridge already establishes that questions never submit from read-only surfaces (share page, headless); approvals get the same by construction rather than by a check someone must remember.
  • State derived from the durable log. The ask card computes answered-ness from message order rather than holding a flag, which makes it replay-correct. Here the durable log is the ledger.

And one thing it deliberately does not do: it never judges whether the granted bounds cover the request. That verdict is the gate's (§5.1 rule 2). A card that second-guessed it would either contradict the gate or imply an authority it lacks — so it reports what was granted and lets the gate decide what that permits. A narrower warrant still reads granted, carrying its actual bounds.

Fail-closed twice: an expiry exactly at now reads expired, and an unparseable expiry reads expired rather than live.

approval-bridge.ts — and why it is not ask-bridge

ask-bridge.send submits text as the user's next chat message. Routing an approval through it would mean the agent reads that message and then writes the ledger row itself, leaving the only durable provenance reading "the agent says the user approved."

approve reaches amico ledger approve instead — the ledger's single writer. The separation is the point; there is a header comment saying not to merge the two bridges.

Verification

packages/ui 352 pass (336 + 16 new), tsgo --noEmit clean.

Known gap

Nothing emits an approval request yet — the card has no trigger (recorded as G-9). The resolution is to derive it from the launch gate's structured refusal, which now exists on the amico-run side (amicode#218), so no new agent tool surface is needed. The .tsx component follows once that refusal is plumbed across the process boundary.

🤖 Generated with Claude Code

kateebonner and others added 4 commits July 28, 2026 18:51
The pulse chip opens the Run Inspector while still un-banked (766e201),
but it kept the dotted border — the exact grammar every OTHER pending chip
uses to mean inert — so its clickability was only discoverable on hover.
Add an always-visible trailing chevron (neutral muted ink, bumps to base on
hover/focus) so the shape signals the action at rest; the dotted border
still tells the truth about not-yet-banked state. Also retire the stale
comment claiming the recorded chips' soft-yellow fill (removed 2026-07-24).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The solver row said "API key connected" and "Runs in the cloud" — which cloud
was never stated, on the one control where a user commits to a paid service.
Every refusal they can hit downstream now says "Harmoniqs Cloud" (amico-run's
local-launch refusal, the hpc gate), so the control that sends them there should
use the same name.

The connected tooltip also states the consequence — every solve on this solver
runs there — since that is the thing users were guessing about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: "where is our harmoniqs cloud, where can I put an API key in" — and
separately, switching to Piccolissimo + Altissimo appeared to do nothing.

Both trace to amicode#200. It filtered company-compute OUT of the Connections
tab and renamed it "Solver API key", moving the whole connect flow inside the
solver capsule's collapsed card. The reasoning was sound in isolation (one
credential for one service, not a second product), but the result was that
Pasqal Cloud rendered as a connectable service in the place that lists clouds
and OURS did not. Users went looking for it exactly where Pasqal is.

- statusTabConnections() no longer filters. Harmoniqs Cloud renders as a card
  beside Pasqal Cloud, with the base-url-token form (Service URL + API key) the
  tab already supported for non-Pasqal ids — so this is un-hiding a working
  flow, not building a new one. Kept as a function so there is still one obvious
  place to filter a genuinely internal connection later.
- connectionTitle() returns "Harmoniqs Cloud". In a list whose other entry is
  "Pasqal Cloud", "Solver API key" read as a settings field rather than our
  service — and Harmoniqs Cloud is the name every downstream refusal already
  uses (amico-run's local-launch refusal, the hpc gate).
- The solver row now states its connection state (CONNECTED / CHECK KEY / ADD
  KEY). With a key already on file, clicking HP correctly activates without
  re-asking — but the status dot lived inside the collapsed card, so the row had
  no way to say so and a successful switch was indistinguishable from a no-op.
  Text rather than a bare dot: Kate's idiom is that the dot accompanies words,
  and words survive colour-blindness and a screenshot. It also says what a click
  will do.

The wire id stays company-compute — renaming it would break the credential
route, the status cache, and CONNECTION_IDS for no user-visible gain. Both entry
points write the same credential, so connecting in either shows up in both.

ui: 57 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not every tree node was openable, but nothing said so: turn nodes wore a
pointer cursor that clicked into nothing, and files in non-browsable
(proprietary) vaults dead-ended in a Vault-panel refusal after the click.

- GET /amicode/vaults now stamps each mount with `browsable`, computed by
  the vault-browser's fail-closed law (deployment gate + kind/marker rules)
- tree leaves in a non-browsable mount render locked: dimmed dot, padlock
  glyph by the label, not-allowed cursor, click is a no-op, and keyboard
  nav announces "locked — this vault does not allow browsing"
- cursor law is now honest everywhere: pointer only where a click opens
  something; grab (pan) on turns/skills/agents/actions; the click ring
  only fires on openable nodes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jack-champagne
jack-champagne force-pushed the fix/amicode-receipt-currency branch from 78ee045 to 7639a8f Compare July 28, 2026 22:52
kateebonner and others added 15 commits July 28, 2026 19:01
…ne inspector entry

The chip and the button opened the same panel from the same row; with the
chip now carrying an at-rest affordance, the button was redundant chrome
(Kate 2026-07-28). Comments referencing the button updated to name the
pulse chip as the rail's inspector entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
formatFidelity's toFixed(4) collapsed any F ≥ 0.99995 to "1" in the run
chip, ring-2 Run hero, and Run modal — hiding exactly the number that
matters. Precision now extends a digit at a time (cap 10) until the gap
from 1 (or 0) survives rounding; ordinary values keep the 4-decimal form
and only a true 1 renders bare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
style(rail): at-rest chevron on the pending-but-clickable pulse chip
fix(run): fidelity readout never rounds a near-unity F to a bare 1
… open

With the context-tree block open the sticky header grows to ~300px and the
band sits inside the pl-4 gutter — backdrop-blur(10px) samples transcript
content through that transparent 16px strip and smears it into a grey
streak along the straight left edge (worst in light theme). The band is
opaque below the 48px title strip, so the blur contributed nothing there.
Slim headers keep the frosted fade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(session): header backdrop-blur off while the context-tree band is open
…ance

fix(amicode): land #84 on mainline (locked vault context) — stacked-merge miss
fix(amicode): superseded receipts render their captured diff, not the live view
feat(connections): Harmoniqs Cloud is connectable where Pasqal is
…t tests (#98)

The three `session.llm native recorded` scenarios are the only failures on
`local/amicode`, so the fork's `test` job is red and every PR into it inherits a
red check — including #93, whose own suite is 3366 pass / 3 fail with zero
failures of its own.

Cause is ours. AMICODE-PATCHES.md ADDENDUM 2 consolidated the prompt so
`system.ts provider()` returns `[providerBase(model), PROMPT_COMMUNICATING]`,
appending `communicating.txt` for every model family. The cassettes still record
the pre-patch prompt, so all three break on a diff of prose.

Those tests assert TRANSPORT mechanics — that a tool loop is driven to a final
text answer. The prompt is incidental, so matching on it couples them to text
they do not test. Patching the recorded prompt instead would work until the next
prompt edit, and that patch already has three addenda.

`RecorderOptions.match` is an existing seam (types.ts:85, threaded through
`http()` to `recordingLayer`) that nothing used. Adds `promptAgnosticMatcher`
alongside `defaultMatcher` and passes it from this one test.

The prompt arrives in three shapes, all covered:
  instructions            OpenAI Responses
  system[]                Anthropic Messages
  input[].role=="system"  the OpenCode proxy

System-message content is replaced with a placeholder rather than deleted, so
"a system message is present, with content" stays part of the match.

Verified narrow, in test/matching.test.ts: it ignores prompt prose in all three
shapes, and still rejects a different user message, model, tool set, URL, or a
missing-vs-present system message. 49 pass in http-recorder; the three recorded
scenarios go 3 fail -> 3 pass; typecheck green across 23 packages.
…s inventing one (#93)

* feat(amicode): per-component physics rows on the system card

The card made its component-count claim implicitly, so an unanswered 'how many
atoms?' read as 'one'. systemCountLabel now states it as its own badge ('2 atoms
x 3 levels') that a researcher can read and correct.

componentPhysicsRows renders what each role actually has, in card order:
- unanswered params read 'not set', so the list doubles as the interview's to-do
- anything else on file is still shown, so nothing recorded is dropped
- an unrecognized role expects NOTHING -- the honest floor for a platform we have
  no model for
- units are never assumed (transmon params are GHz, the Rydberg templates work in
  rad/us, a bare omega says which only by convention), so a unit appears only when
  the recorded key spells it out
- a zero keeps the '0 means unset' reading, so an all-zeros seed does not look
  like a specified device

driftTerm now keys the qubit ladder off LEVELS rather than off whether delta
happens to be filled in: a 3-level transmon with empty params is still an
anharmonic ladder, and rendering it as a bare spin misstates the model the solve
will use. A mode's Kerr stays params-driven, since a linear cavity genuinely has
none.

* feat(amicode): extend the system card's physics rows

Continues 0793fd6. Committed on the author's behalf as part of folding the
worktree's in-progress work onto the branch; the content is theirs.

+169 lines of tests alongside +278 of implementation; ui suite 442 -> 448 pass.

* feat(amicode): render the recorded Hamiltonian; stop inferring one off-template

The card re-derived the Hamiltonian in the renderer from (role, levels, platform)
using hardcoded tables, so it could only ever be right for platforms someone had
hardcoded. Two separate leaks put the transmon model on a spin qubit:

- `platformDefaultRole` maps every unfamiliar platform to "qubit", so an
  exchange-only HRL spin qubit arrived indistinguishable from a transmon.
- levels >= 3 was read as "anharmonic ladder". It is not. Three levels is a
  Hilbert-space DIMENSION: an exchange-only qubit at levels=3 is three dots, a
  spin-1 defect is three Zeeman sublevels. Neither is an oscillator.

So the platform is now the only thing that licenses a ladder for a `qubit` role
(transmon, bosonic). levels=2 still earns the generic two-level model, which is
safe on any platform — every two-level system has an ω σ_z/2 splitting and σx/σy
control. Everything else is opaque.

Recorded beats inferred. `systemHamiltonian()` returns {latex, source}: a model
recorded on the entity (amicode_set_model's `hamiltonian`, see harmoniqs/amicode#220) renders
verbatim; otherwise the canonical form for the platform renders under an
"inferred · confirm or correct" label. When nothing is recorded AND nothing is
modelled the card says "not recorded" rather than showing `Ĥ_drift + Ĥ_c(t)`, which
is true of every control problem ever posed and would occupy the slot the real model
belongs in. Mixed systems still get a named placeholder per unmodelled component.

One `classify()` now drives both the equation and the physics rows, so the two
halves of the card cannot describe different physics again.

Also fixes a three-way drift found while doing this: the ladder drive was a single
`ε(t)(â+â†)` while the plugin's TRANSMON_LATEX (shown in chat) and Piccolo's
n_drives = 2 both say two quadratures. Two is correct; the card now matches.

Verified: 399 pass across 30 files, tsgo clean. The KaTeX sweep now crosses the
platform axis too (8k systems) and asserts BOTH directions: no output when nothing
is modelled, and never a model invented for an all-unmodelled system.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(amicode): dedupe the KaTeX sweep — render each distinct equation once

The sweep enumerates ~8k systems, but they collapse onto a few hundred distinct
equations, and KaTeX is the expensive part. Rendering the same string 200 times
proves nothing and took 22s on CI against a 5s default timeout (it passed locally
on a fast machine, and I had only been running src/amicode — CI runs all of src).

Coverage is unchanged: every distinct output is still parsed, and the mapping back
to a producing system is kept for the failure message. Adds an assertion that the
sweep really does vary its output, so a future change that collapses everything to
one equation can't quietly pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Spec-20260727-164748 §9.5. Two of S3's three modules, mirroring the repo's
existing ask.ts / ask-bridge.ts / ask-card.tsx split. The .tsx component is
NOT here — see the gap note below.

approval.ts (pure): the card's state machine over (request, warrants, now,
hasBridge) → unavailable | pending | granted | expired. Two inherited
properties and one deliberate divergence, all from §9.5:

  - Inherits the read-only interlock: no transport → `unavailable`, never
    actionable. Absence of a host is the safe state, so it is the default.
  - Inherits state-derived-from-the-durable-log: the state is a function of the
    approval rows that exist, never a stored UI flag, so it is replay-correct
    the way ask-card's hasUserReplyAfter is.
  - Does NOT judge whether granted bounds COVER the request. That verdict is
    the gate's (§5.1 rule 2); a card that second-guessed it would either
    contradict the gate or imply an authority it lacks. The card reports what
    was granted, the gate decides what that permits.

Fail-closed twice over: an expiry exactly at `now` reads as expired, and an
UNPARSEABLE expiry reads as expired rather than live — same direction the gate
applies to an unresolved estimate (§4.4). boundsText renders only declared
bounds, since an absent bound is not "unlimited".

approval-bridge.ts: a SEPARATE bridge from ask-bridge, and that separation is
the point. ask-bridge's `send` submits text as the user's next chat message;
routing an approval through it would leave the ledger's only provenance reading
"the agent says the user approved". `approve` reaches `amico ledger approve`
(CLI-3) instead — the ledger's single writer. Disposer is registration-aware so
a late unmount cannot wipe a newer bridge.

packages/ui 352 pass (336 + 16 new), tsgo --noEmit clean.

GAP FOUND (recorded as G-9 in the spec): nothing emits an approval REQUEST.
There is no amicode_* tool for the agent to ask for one, so the card has no
trigger. Writing the component before that surface is decided would be
inventing a tool contract silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec-20260727-164748 §9.5. Completes the UI half: a gate refusal can now become
a button instead of prose telling the researcher to go run a CLI verb.

The path: amicode_request_approval (amicode-side plugin tool) renders
AmicodeApprovalCard from the tool INPUT — the ask card's pattern, not a sentinel,
because a request is an ASK rather than a record of something that happened.
Pressing Approve goes through approval-bridge → POST /amicode/approve → shells
`amico ledger approve`. The card then REFETCHES rather than optimistically
flipping, so "granted" is always something the ledger said.

Three deliberate differences from the ask card it otherwise mirrors:

  1. Transport. ask-bridge submits as the user's next CHAT MESSAGE, which would
     mean the agent reads it and writes the ledger row — leaving the only
     provenance as "the agent says the user approved". Hence a sibling bridge,
     and `issued_by: user:ui` so an audit can tell a press from the CLI.
  2. State source. Derived from the LEDGER (is there a live warrant for this
     plan_hash?) rather than from message order. Equally replay-correct, survives
     reload, and cannot disagree with what the gate will see.
  3. No coverage verdict. The card never claims the granted bounds are enough —
     that is the gate's call (§5.1 rule 2). It shows requested, and once granted,
     what was ACTUALLY granted, which may be narrower. A card reading "approved"
     over a warrant the gate will still refuse is worse than no card.

Server side keeps the single-writer rule (#212): reading the ledger here is a
plain file read, but writing shells the CLI. An append from this process would
break O_APPEND atomicity quietly and only under concurrency. Route and card are
both tolerant per row — a malformed warrant is dropped, never defaulted into one.

The bridge registers only when BOTH halves are present; a card that could approve
but not read back its own warrant would show "pending" forever after a press.
Omitting them leaves the card non-actionable, which is the correct behaviour for
a share page or headless host.

ui 378 pass · app 442 pass · opencode server 550 pass / 15 fail, against 538 / 16
on the clean base — the failures are the documented pre-existing widget-src env
set, verified by stashing. tsgo clean in ui and app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of the same clutter, seen in a live session: five identical
"Recommend updated ✓" chips in a row.

Cause was NOT the INLINE_KINDS change — `recommend` was never an inline kind, so
it has always rendered as a chip. It read as identical five times because
amicode_recommend emits no diff sentinel, so the chip had nothing to distinguish
one call from the next, and the interview fires one call per knob. What changed is
only that the System cards above them shrank, so a run that used to be buried
became the tallest thing on screen.

The plugin side (amicode) now emits a sentinel naming the param, so the run reads
`Recommend · N 100` / `Recommend · Q 1e5` rather than five copies of one line.
Five DISTINCT lines are a record; five identical ones are noise.

That alone would have introduced a worse bug: Chip's clickable() was `!!parts()`,
so any sentinel made the chip openable — and entity-view.tsx has cases for only
the five real kinds, so clicking a `recommend` receipt would have opened an empty
dialog. Clickability is now gated on the kind actually having an entity view.
Diff detail and openability are separate properties and were conflated.

Also records a gap in the spec's §9.3: it specifies at most one EXPANDED card and
says nothing about runs of identical COLLAPSED receipts — the same defect one tier
down. Coalescing a run (the repo already has AnimatedCountList for exactly this
shape) is the follow-up, but naming them first may make hiding them unnecessary.

ui 378 pass, tsgo clean. extension 777 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec §9.6 / G-6, which settled the rail over the context-tree panel on Kate's own
ADR 0003 framing: the panel holds "a map of distinct context, not a log", and
warrant bounds are authorization state rather than context.

Smaller than the spec implied, because building the approval card already plumbed
warrants into the rail as a prop — the "adds a second data source" objection was
resolved as a side effect. What actually remained was the solve count and the chip.

`/amicode/warrants` now counts `solve` rows per plan in the SAME ledger pass that
collects the approvals, so the numerator can never be a stale second counter (§4.5:
the ledger is the count-things store). `solves_used` is optional end to end — a
surface that does not supply it gets a chip with no count rather than a wrong
"0 of 8".

railWarrantChip returns UNDEFINED when there is nothing true to say: no live
warrant, or a live one declaring no bounds. A chip reading "warranted" with nothing
behind it would imply an authorization the gate does not grant, since §5.1 rule 2
refuses a launch needing a bound the warrant omits. Silence is the honest state.

The chip is inert on purpose, and reports CONSUMPTION rather than permission.
"3 of 8 solves" is a ledger fact; whether the next launch passes is the gate's
verdict. Same reason the card carries no coverage claim — two surfaces, one rule.

ui 384 pass · app 442 pass · opencode warrants 12 pass · tsgo clean in ui and app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aarontrowbridge

Copy link
Copy Markdown
Member Author

Superseded by #106 — the approval-surfaces commits are all contained in the consolidation (zero unique ahead). If part 2 of warrant surfaces resumes, rebase onto the post-merge local/amicode. Closing; ships via v1.18.10-amicode.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants